✨ feat(fmt): a string-formatting engine unifying repr, str, format, and the IPython reprs - #855
Conversation
|
@adrn the goal isn't to make a package called |
There was a problem hiding this comment.
Pull request overview
This PR introduces a new unxt.fmt formatting engine that unifies how repr, str, format, and IPython rich representations are produced, enabling new types to participate by registering a single pparts implementation. It also updates unit system rendering to ensure repr round-trips via eval(repr(usys)) == usys, and aligns documentation/tests with the new output.
Changes:
- Added
unxt.fmt(public) andunxt._src.fmt(implementation) providing a parts-based formatting pipeline for text/HTML/LaTeX plusFORMAT_PRESETSfor f-string specs (e.g.:compact,:mul,:latex). - Reworked unit system rendering via a shared
AbstractUnitSystem.__pdoc__, updated dataclassrepr=Falsefor all unit-system shapes, and added tests forreprround-tripping and readablestr. - Routed Quantity IPython markup reprs (
_repr_html_,_repr_latex_) through the new engine and registeredUnitsMatrixparts to preserve structure and prevent LaTeX slicing regressions.
Reviewed changes
Copilot reviewed 23 out of 23 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| tests/unit/test_unitsystems.py | Adds property-style tests ensuring unit-system repr round-trips and str/dims formatting behaves as intended. |
| tests/unit/test_quantity_printing.py | Adds tests covering format presets and the __pdoc__ custom-hook chaining behavior. |
| tests/unit/test_fmt.py | New focused test suite for unxt.fmt presets, markup escaping, layout behavior, extensibility, and JIT interactions. |
| src/unxt/fmt.py | New public module re-exporting the formatting engine API. |
| src/unxt/_src/unitsystems/flags.py | Updates doctest outputs for unit-system rendering changes. |
| src/unxt/_src/unitsystems/core.py | Updates doctests and ensures dynamic unit-system dataclasses don’t shadow base rendering (repr=False). |
| src/unxt/_src/unitsystems/builtin.py | Switches built-in unit-system dataclasses to repr=False; adds DimensionlessUnitSystem.__pdoc__. |
| src/unxt/_src/unitsystems/base.py | Adds round-tripping unit-string logic and centralizes unit-system rendering in __pdoc__; updates __repr__, __str__, __format__. |
| src/unxt/_src/quantity/mixins.py | Routes IPython HTML/LaTeX repr methods through the new formatting engine. |
| src/unxt/_src/quantity/base.py | Fixes __pdoc__ custom hook clobbering by chaining hooks; routes __format__ through pspec; adds _chain_custom. |
| src/unxt/_src/fmt.py | New core formatting engine: parts tree model, doc/markup consumers, preset table, and fallback behavior. |
| src/unxt/init.py | Exposes fmt at the package top level. |
| README.md | Updates unit-system rendering examples to match new repr. |
| packages/unxts.linalg/tests/test_printing.py | Adds regression tests ensuring QuantityMatrix markup output is correct (esp. LaTeX). |
| packages/unxts.linalg/src/unxts/linalg/_src/_units_matrix.py | Registers UnitsMatrix with unxt.fmt.pparts to render structured unit tuples correctly. |
| packages/unxts.interop.gala/docs/index.md | Updates doctest output for unit-system rendering. |
| packages/unxts.interop.gala/docs/guide.md | Updates doctest output for unit-system rendering. |
| packages/unxts.interop.gala/docs/api.md | Updates doctest output for unit-system rendering. |
| docs/interop/dataclassish.md | Updates example output to reflect new str rendering of unit systems. |
| docs/index.md | Updates doctest outputs for unit systems to match new repr. |
| docs/guides/units_and_systems.md | Updates doctest outputs for unit systems to match new repr. |
| docs/api/index.md | Adds fmt to the API docs toctree. |
| docs/api/fmt.md | New API docs page for unxt.fmt. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #855 +/- ##
==========================================
+ Coverage 99.80% 99.83% +0.03%
==========================================
Files 84 88 +4
Lines 4017 4340 +323
Branches 313 366 +53
==========================================
+ Hits 4009 4333 +324
Misses 4 4
+ Partials 4 3 -1 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
galax declares `unxt>=1.11.2` with no upper bound, but has never been resolved or tested against unxt 2.x -- the lock has only ever held a 1.x. Today nothing declares that boundary: `uv` considers unxt 2.0.0 during resolution (it is compatible on `requires-python`) and backtracks to 1.11.x for transitive reasons. That is incidental, not a guarantee, and it disappears the moment the surrounding constraints shift. Make the tested boundary explicit. The resolved version is unchanged -- 1.11.2, as before -- so this is metadata only; `uv.lock` records the new specifier and nothing else. There is a concrete change coming that this guards against. unxt is making unit system `repr`/`str` round-trippable (GalacticDynamics/unxt#855), which rewrites the `LTMAUnitSystem( length=Unit("kpc"), ...)` form that 32 doctest lines here pin, 18 of them in `_interop/galax_interop_gala/potential.py` and 12 in `..._galpy/potential.py`. Those updates belong with the port to unxt 2.x, not ahead of it. Lift this cap in the PR that does that port. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
ab86974 to
c77438d
Compare
FORMAT_PRESETS is a single dict shared by every type that routes __format__ through unxt._fmt.pspec, so a preset name means the same thing across `unxt` -- that convention was implicit in the code and undocumented outside its own docstrings. Write down the two axes a preset varies along (call-style vs product-style structure, text/html/latex markup), the current 8-preset table, and the rules for adding a preset or joining the engine as a new type. Calls out the one easy-to-miss naming gotcha (`short` vs `compact`, which cut different axes despite sounding like synonyms) and the one type-specific precedent (`dims`). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Every product-style combination needed its own FORMAT_PRESETS entry, so html/latex only ever existed with the multiplication separator baked in -- there was no way to ask for html-bare or latex-short without adding a new dict row per combination, and three independent axes (markup, array verbosity, separator) would need one entry each for every combination. Parse a spec as a set of up to three optional tokens instead: markup (html, latex; default text), array (short; default the compact form), separator (mul, bare; default mul). Order doesn't matter -- "html-bare" and "bare-html" are the same request -- but "<markup>-<array>-<separator>" is the canonical spelling used in docs and error messages. The existing single-word presets (mul, bare, short, html, latex) are now the case where every component but one is omitted, not a separate alias table, which is what makes "html" mean "html-mul" for free rather than needing its own entry. FORMAT_PRESETS keeps only the call-style entries (full, compact, dims) that don't fit this grammar. bad_spec's error message and the pspec/pspec_fallback docstrings describe both. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…e into the DSL
The array component was share/compact only, and the unit always rendered its
short/symbol name -- a spec like ".3g" only ever worked scalar-only, through
the unrelated pspec_fallback path, with no way to combine it with markup, a
separator, or a summary array. Extend both axes so they compose with the rest
of the grammar:
- The array component may carry a trailing Python format spec, applied per
element via numpy.array2string's formatter (e.g. "mul-.3g", "compact-.3g",
or bare ".3g" combined with any other token). "compact" is now a nameable
array token (previously implicit-only) so it has something to attach a
value spec to on its own. "short" (a shape/dtype summary) has no per-element
values to format, so combining it with a value spec is rejected.
- A new unit component, "long", renders astropy's long_names in place of the
short/symbol form, falling back to the short form (not raising) for a
composite or dimensionless unit with no long name.
A value spec still needs a real DSL token to activate this path -- a bare
".3g" or ":>10" (":" as a fill character) has no keyword in it at all, so it
is untouched and keeps going through the unchanged, scalar-only
pspec_fallback, exactly as before.
Fixes a collision this surfaced: "compact" is both a pre-existing call-style
FORMAT_PRESETS key and now a valid array-component token. pspec checks
FORMAT_PRESETS before parsing the DSL so a bare f"{q:compact}" keeps its
original meaning; only "compact" combined with something else (e.g.
"compact-.3g") reaches the DSL.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…"values"
"compact" was both a pre-existing call-style FORMAT_PRESETS key and the array
component's nameable default-form token -- and never actually load-bearing as
the latter: a bare "compact" is always intercepted by FORMAT_PRESETS (checked
first), and "compact" combined with anything else is indistinguishable from
using any other real token (e.g. "mul", already a pure no-op default itself)
as the anchor. So "compact-.3g" and "mul-.3g" always produced the identical
parsed spec -- the word bought no expressiveness, only a naming collision.
Rename it to "values", which cannot collide with any FORMAT_PRESETS entry and
still reads clearly ("show the values, at this precision"). The ordering fix
(FORMAT_PRESETS checked before the DSL parse) stays regardless, as general
defensive design for the next token that gets added.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The format DSL had two disjoint systems -- a FORMAT_PRESETS table for
call-style renderings and a keyword DSL for product-style ones -- and three
root causes between them:
- Two systems that could not compose. `html-compact` leaked past the DSL into
float.__format__ and raised "Invalid format specifier 'compact' for object
of type 'float'".
- The value spec was parsed *by elimination* ("whatever is left after the
keywords"), so `mul-.2f-.3g` silently rejoined into one garbage spec.
- The value spec had two implementations. `.2f` went through pspec_fallback
(scalar-only, space-joined); `mul-.2f` went through the engine (array-
capable, '*'-joined) -- so the same spec meant different things depending on
whether an unrelated keyword happened to be present.
Replace all of it with one grammar. A spec is a '-'-joined run of keywords,
then an optional Python format spec applied per element. The parse is total
and left-to-right: consume keywords, and the first non-keyword token ends
keyword parsing -- everything from there, including any further '-', is the
value spec. That single rule removes the elimination pass and makes a format
spec's own '-' (sign flag "-.2f", fill char "->10.2f") unambiguous.
Six axes, one flat pairwise-disjoint keyword namespace (enforced by a test,
which is what makes order-independence sound rather than hopeful): layout
(call|product), value (array|values|type), markup (text|html|latex), unit
(symbol|name|dim), separator (mul|bare), abbreviation (abbrev). Naming an axis
the chosen layout lacks is a typed error, never a silent no-op. Aliases
(compact, full, dims) expand textually into core keywords, so they can never
mean anything the grammar cannot say.
repr and str are now the same renderer reached with a different Spec, with
unxt.config supplying the components -- the empty spec stops being a special
case. The public config traits keep their spelling; VALUE_FROM_SHORT_ARRAYS is
the one place the two vocabularies meet. call layout still routes through
__pdoc__, which is load-bearing: that is where reconstruction lives, so
eval(repr(usys)) == usys still holds.
BREAKING CHANGE: the product-style default separator is now `bare`, so
`f"{q:.2f}"` is '3.14 m' (astropy-shaped) and `f"{q:html}"` no longer shows a
'*'. `short` is renamed `type` and `long` is renamed `name`. FORMAT_PRESETS
and pspec_fallback are gone. A value spec now formats each element of an
array instead of raising TypeError.
Also fixes value_str collapsing the full-array form onto the type summary:
it pinned short_arrays=True and always passed the summary-building custom
hook, so `array` and `type` rendered identically.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ponytail-review pass on the new grammar. Three duplications, all of them free to drift out of sync: - `parse_spec` spelled out every axis default a second time as `seen.get(axis, default)`, alongside the `Spec` field defaults. Axis names are exactly `Spec._fields`, so the scanned axes go straight in as `Spec(**seen, value_spec=...)` and unset ones fall to the field default. Validation moves after construction and reads the built spec. A test pins the axis-name/field-name correspondence the call now relies on. - `VALUE_FROM_SHORT_ARRAYS` was hand-written as the exact inverse of `_SHORT_ARRAYS`; derive it by inversion instead. - `_SEPARATORS` was a two-entry table read at exactly one call site; inline it there. No behaviour change. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…registrable The engine's docstring claimed it "knows nothing about quantities, units, or unit systems". That was false, and a downstream package could not in fact extend it -- registering an axis died with `TypeError: Spec.__new__() got an unexpected keyword argument`, because `Spec` was a closed NamedTuple. Five couplings, now cut: - `Spec` is a frozen Mapping, so a downstream axis is read exactly like a built-in one. Build with `Spec.of(**overrides)`, which fills defaults from the registry -- writing the mapping out by hand leaves a hole that surfaces as a KeyError once a later axis is registered. - The vocabulary is a registry. `register_axis` / `register_alias` replace the hardcoded `_KEYWORDS` / `_LAYOUT_AXES` / `ALIASES` tables. An `Axis` carries its keywords, default, and a per-layout translation to renderer kwargs -- where membership *is* applicability, so the layout-scope check falls out of the same table. - `render` no longer hardcodes unxt's `__pdoc__` kwargs. `quote_units` and `show_units` are what the `abbrev` and `unit` axes translate to, declared in unxt's layer, and the product renderer forwards every axis it does not own to `pparts` -- which is what lets a type act on an axis the engine has never heard of. - jax/numpy leave the engine. `value_str` and the `custom_pdoc_*` hooks were only ever called by consumers. - `unxt._src.fmt` becomes a package: `engine` (domain-agnostic, imports nothing from unxt/jax/numpy/astropy) and `axes` (unxt's axes, aliases and array helpers). coordinax and galax add theirs as peers of `axes`. Three tests hold the seam: one asserts the engine's import list stays clean, one registers a fake downstream axis and checks it is indistinguishable from a built-in (order-independence, defaults, aliasing, layout scoping), and one checks registration rejects a name collision in both directions. That last one caught a real gap while being written: `register_alias` guarded against keyword collisions but silently overwrote an existing alias -- the exact failure this registry exists to prevent. BREAKING CHANGE: `Spec` is a Mapping, not a NamedTuple -- `spec.markup` becomes `spec["markup"]`, and construction goes through `Spec.of`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ract `unxt._fmt` re-exported 23 names, most of them engine internals that nothing outside the engine calls. Widening a surface later is easy and narrowing it is not, so export only what a downstream package provably needs and let the rest be reached at `unxt._src.fmt` until something outside this repo asks: - join in: pparts, PPart, PGroup - extend the grammar: Axis, register_axis, register_alias - render: pspec, render, Spec That is 9. Dropped: ALIASES, AXES, MARKUPS, REQUIRED_MARKUP_KEYS, VALUE_FROM_SHORT_ARRAYS, bad_spec, custom_pdoc_no_kind, custom_pdoc_noarray, doc_to_str, parse_spec, parts_to_doc, parts_to_markup, unwrap_math, value_str. None had a caller outside `unxt._src`; unxt's own modules already reach the engine by the internal path, so nothing moved but the doctest imports. The shim's `pparts` example now renders through `pspec` rather than `parts_to_markup`, which is both the public path and the one a downstream type would actually take -- and it gained a case showing the unit axis reaching nested quantities through a type that has never heard of it. The docs' axis example is likewise executable now, so "a downstream axis is first-class" is checked rather than asserted. Also drops a stale `FORMAT_PRESETS` reference left in a docstring by the grammar rewrite. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
7d26d02 to
84631ab
Compare
Ponytail-review pass: - The two layout names were written three times: as `_LAYOUTS` keys, then again as the layout axis's `keywords` and its `layouts`. Derive both from `_LAYOUTS`, so the names live in one place. - `pspec` was missing from `engine.__all__`, so the package `__init__` carried a bespoke `from .engine import pspec` and a hand-added `"pspec"` in its own `__all__`. Export it properly and both special cases go. - `Spec.__hash__` restored hashability that nothing uses -- no set, dict key or `hash()` call anywhere. `Mapping` makes subclasses unhashable on purpose; let it. A fourth finding was wrong and is not applied: the `gap` and `pm` LaTeX role overrides looked speculative, but the grep behind that call covered `src/` and `packages/` and not `tests/`. Both have real consumers there -- they are what `test_a_markup_may_override_a_new_role` and the nesting tests exercise, i.e. the very demonstration that a new role costs one `MARKUPS` entry. Removing them turned that test red, which is the test doing its job. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…arts`
The engine already documented that it is meant to be lifted out into a package
of its own. Say which package, and spell the module so that extraction is
dropping a single underscore:
from unxt._pparts import pparts, PPart # today
from pparts import pparts, PPart # after extraction
with no other edit at the call site. The nine names re-exported from
`unxt._pparts` are exactly that package's intended surface.
`pparts` is named for the extension point everything turns on -- a type joins
in by declaring what it is made of -- and keeps the `p`-prefix `wadler_lindig`
already uses (`pformat`, `pdoc`, `__pdoc__`). That is the honest form of the
kinship: this engine *feeds* wadler-lindig rather than reimplementing any part
of its algorithm, so naming it after another prettyprinting author -- the
obvious way to look like a sibling -- would claim something untrue. Checked
free on PyPI, as were `polyrepr`, `reprism` and `morpheme`; `reprise`,
`compositor`, `galley` and `prism` are taken.
Only the top-level shim is renamed. `unxt._src.fmt` keeps its name: renaming
that package too would shadow the `pparts` *function* at every registration
site, where `@fmt.pparts.dispatch def pparts(...)` is the established shape.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The trailing Python format spec was a reserved `Spec` key beside the axes,
not a value of one. Two problems came with that:
- It made an invalid state representable. `value` and `value_spec` described
one thing -- how the payload renders -- so `Spec(value="type",
value_spec=".2f")` was constructible and meaningless, and needed a
hand-written consistency check to reject it.
- It leaked unxt's vocabulary into the domain-agnostic engine. That check read
`resolved.get("value") != "values"` and `layout != "product"`: the axis
name, the keyword and the layout name are all defined in `axes.py`, unxt's
layer. The engine/axes seam test could not catch it, because those are
string literals rather than imports.
An axis may now declare `free_text=(<layouts>)`, saying its value may be
arbitrary text instead of a keyword in those layouts. unxt's `value` axis
claims it for `product`. The engine's job becomes generic: append the trailing
run to whichever axis claims free text, and error if that axis is already set
or the layout does not accept text. At most one axis may claim it, enforced at
registration -- the scan rule makes the trailing run terminal, so there is only
one to give.
`type-.2f` is now caught by the ordinary "value is set twice" rule, and no
axis name, keyword or layout name is hardcoded in the engine any more.
BREAKING CHANGE: `Spec` no longer has a `value_spec` key. `spec["value"]` is
either a keyword value or the format spec; ask `free_text_of(spec)` which.
Docs restructured to match: `conventions.md` keeps a short statement of the
conventions plus an example, and the full grammar, axis table, extension and
layering docs move to a new `guides/formatting.md`. A reference manual had no
business in a conventions page.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…lper Ponytail-review pass on the free-text machinery: - `_FREE_TEXT_AXIS` was a module-level list standing in for a nullable singleton, hand-maintained by `register_axis` and read via `[0]` in four places. Derive it from `AXES` instead, so it cannot fall out of step with the registry -- the same fix as `VALUE_FROM_SHORT_ARRAYS`. - `free_text_of` was a public-shaped helper with one caller and no export. Inline it into `pspec` until a second caller exists. - Dropped the "no axis accepts free text" guard: unreachable here, since unxt's layer always registers one, and untested. Speculative cover for a state the standalone package does not have yet either. Also corrects the previous commit message, which told downstream to ask `free_text_of(spec)`. That name was never exported, so the instruction did not work as written; it is now gone rather than half-available. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…olds A verbosity pass over the engine's comments and docstrings, keeping rationale a current reader needs and dropping post-mortems of superseded revisions: - `parse_spec`'s inline comment argued its design against the old `value_spec` key -- a comparison no reader of this code can see. - `value_str` carried eleven lines of comment over two lines of code, most of it explaining a bug that no longer exists. The operative facts stay: the hook builds a summary, so it belongs only on one path and is omitted rather than blanked. - `unwrap_math` cited the `_repr_latex_` defect by name after already stating what goes wrong. - The module docstring's naming rationale is a decision record; it lives in the commit that made it and in the guide. - `_pparts` restated the whole contract in prose written before `guides/formatting.md` existed. It keeps the name list -- that is what a downstream reader wants without leaving the module -- and points at the guide for the grammar and worked examples. Kept deliberately: `PGroup`'s two bullets, which are what stop someone "simplifying" the nesting into invalid LaTeX, and `_render_call`'s note that `__pdoc__` is where reconstruction lives, without which the indirection looks pointless. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Order-independence requires a flat keyword namespace: a bare token has to
identify its axis without help from position. The cost is that every axis
competes for one pool of words, and unxt has already claimed seventeen of the
most generic -- dim, name, type, full, text, array, values among them.
The failure that mattered was not "a name is taken" but whose error it was.
`coordinax` wanting `dim` for manifold dimensionality -- a different and
equally valid meaning -- was refused at *registration*. Two sibling packages
each wanting one word meant `import coordinax; import galax` raised in user
code, with neither library at fault and no fix available to either.
A keyword may now be written `axis:word`. Bare resolves while exactly one axis
claims it, which is every word today, so nothing changes for ordinary use.
When two claim one, the bare form is ambiguous and the error names both
qualified alternatives. Qualification is per-token: a collision costs one
prefix on one word, not a rewritten spec.
Registration no longer refuses a duplicate keyword. It still refuses a clash
with an *alias*, which is a whole spec and so has no qualified form to fall
back on.
A `:` naming no registered axis is not a qualifier -- it falls through to the
format spec like any other non-keyword, so a `:` fill character still works
(`f"{q::>6}"` -> ':::::3 m', verified).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An axis is offered to every type and quietly ignored by one with no concept of
it. That is deliberate -- it is what lets a composite forward axes it has never
heard of, so `f"{interval:name}"` reaches the nested quantities -- but it means
a spec can be accepted and do nothing. Two live examples:
- `f"{q:name}"` on `km / s`: a composite unit has no long name, so the axis
falls back to the symbol.
- `f"{q:dims}"` on a Quantity: the alias expands to `call-dim`, which only
unit systems honour. Identical output to `f"{q:call}"`.
Applicability cannot be declared per type -- a composite genuinely does not
honour `unit`, yet forwarding makes it work, and the answer depends on the
object *tree*, which is not known before rendering. So ask by experiment
instead: render again with the axis reset to its default and see whether the
output moves. That needs no cooperation from the type and sees through
composites for free. Measured against both warts above and the live cases
(`name`/`dim` on a simple unit, `mul`, `latex-mul`, `.2f`): no false positives.
Off by default, because each probe is a second render -- 1.7x on a one-axis
spec, 5x on `latex-mul-name`. `repr`/`str` are unaffected either way; they
reach `render` directly and never pass through `pspec`.
The flag is `WARN_INERT_AXES` on the engine rather than a trait in
`unxt.config`, so it survives extraction: a consuming library sets it for
itself instead of the engine reaching into any one library's configuration.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`pylint src/unxt` exited 4 on: - `units.py`: the unit axis's product kwarg was named `unit`, shadowing the `unit()` constructor in that same module (W0621). Renamed to `unit_style` rather than suppressed: in this codebase `unit` means a unit *object*, so the parameter was misleading independently of the linter. An axis chooses the kwarg it emits, so this is a one-line change in `axes.py` plus the two `pparts` signatures that receive it. - `engine.py`: the inert-axis probe catches `Exception` (W0718). That breadth is deliberate -- the probe calls user `pparts` and must never break the render it is only inspecting -- so it is suppressed with that reason rather than narrowed. Verified with the same invocation CI uses: `pylint src/unxt` is back to 10.00/10. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 27 out of 27 changed files in this pull request and generated no new comments.
Suppressed comments (1)
src/unxt/_src/quantity/base.py:99
- This docstring points to
unxt._pparts.VALUE_FROM_SHORT_ARRAYS, butVALUE_FROM_SHORT_ARRAYSis not among the names re-exported by theunxt._ppartsshim (its__all__/imports only coverAxis,PGroup,PPart,Spec,pparts,pspec,register_alias,register_axis,render). The symbol actually lives inunxt._src.fmt, which is what the code just below uses (fmt.VALUE_FROM_SHORT_ARRAYS). Referencing it underunxt._ppartswould mislead a reader following the cross-reference. Consider pointing tounxt._src.fmt.VALUE_FROM_SHORT_ARRAYSinstead.
``use_short_name``); `unxt._pparts.VALUE_FROM_SHORT_ARRAYS` is the single
codecov was the last red check. Five guards in `engine.py` were unexercised and one method elsewhere lost its only caller: - duplicate axis-name registration, `Spec.of` rejecting an unknown axis, and `Spec.__repr__` -- straightforward, now tested. - The probe's broad `except`: a type may legitimately reject the *default* of an axis while accepting the value asked for, and the inert check must skip that rather than turn a working format call into an exception. - `pspec` re-raising a render error when the spec carried no free text: only a *value spec* failure is reworded as a bad format spec, since rewording a keyword-only failure would blame the spec for the type's own error. `StaticValue.__format__` was covered on main and is not any more, and that is this branch's doing rather than a flake: `pspec_fallback` used to reach it via `format(obj.value, spec)`. A quantity's format spec is now applied per element through `numpy.array2string`, so that path no longer arrives there. The method stays -- `format(sv, ".2f")` is still legitimate on its own -- but its docstring claimed it was "hence" how a StaticQuantity formats, which is no longer true. Corrected, with a doctest. The engine, its axes and `_pparts` are at 100%. The two remaining misses in the package (`base.py`, `register_primitives.py`) are identical on upstream/main, verified by running coverage on both. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
@adrn I've updated and reworked the engine. It's more flexible, powerful, and ready for use in coordinax and galax. |
`:` implied namespacing -- "this word belongs to the unit axis" -- but that is
not what happens. A keyword *sets an axis to a value*, so `unit=dim` is the
operation spelled out and bare `dim` is its shorthand. It also mirrors the
keyword argument the axis translates to, so the spec and the Python API read
the same way, and someone meeting `unit=dim` can infer the axis model without
being told.
`=` is an *align* character in a format spec, which is why it was passed over
at first. That was too quick: align only ever appears at position 0 or 1, so
`=8`, `=>8.1f`, `=^10` and `=+9.2f` can never be read as an assignment. Each
is checked to fall through to the format spec.
Candidates ruled out on evidence rather than taste: `axis{word}` is an
f-string nested replacement field -- it raises `NameError`, or silently
substitutes a same-named local, so a spec would mean different things in
different scopes. `axis\word` warns `SyntaxWarning: invalid escape sequence`
in any non-raw literal. `|`, `&`, `;` and `!` are shell metacharacters, and
specs are the sort of thing that ends up in a CLI flag.
Nothing can break: no word is claimed twice yet, so qualification is
unreachable and no spec anyone has written can contain one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`f"{q:latex}"` emitted `$[1.5,~2.5] \mathrm{m}$` -- a *literal* space, which
LaTeX discards in math mode. The default rendering therefore set the unit
flush against the value.
A regression from this PR: `bare` became the default separator (right for
`f"{q:.2f}"` staying astropy-shaped), and `bare` had no LaTeX spelling, so it
fell back to the plain-text `" "`.
The deeper cause is that `sep` substituted a *literal string* chosen by the
axis, bypassing the markup table entirely -- so no LaTeX override could ever
apply to it, and adding one had no effect. `sep` now names the **role** that
stands in for `mul`, which routes it through the same per-role lookup every
other fragment uses. Each markup then spells its own join: `" "` for text and
HTML, `\,` -- the thin space conventionally set between a quantity and its
unit -- for LaTeX.
`mul` keeps `\;` in LaTeX. `1.5 \times \mathrm{m}` is not how a quantity is
written, so there the axis chooses between two spacings rather than showing an
operator.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes #683.
Makes
repr,strand__format__one rendering reached three ways, behind a domain-agnostic engine thatcoordinaxandgalaxcan register into as peers.Formatting a Quantity
Every result below is real output, from
q = u.Q([1.23456, 22.5, 333.75], "m")ands = u.Q(3.14159, "m").Layout — how the pieces are arranged:
f"{q}"Quantity([ 1.23456, 22.5 , 333.75 ], unit='m')f"{q:mul}"[ 1.23456, 22.5 , 333.75 ] * mf"{q:bare}"[ 1.23456, 22.5 , 333.75 ] mf"{q:call}"Quantity([ 1.23456, 22.5 , 333.75 ], unit='m')f"{q:compact}"Q([ 1.23456, 22.5 , 333.75 ], unit='m')f"{q:full}"Quantity(Array([ 1.23456, 22.5 , 333.75 ], dtype=float32), unit='m')Value — how much of the payload shows, including a per-element format spec:
f"{q:type}"f32[3] mf"{q:array}"Array([ 1.23456, 22.5 , 333.75 ], dtype=float32) mf"{q:.2f}"[1.23, 22.50, 333.75] mf"{q:mul-.2f}"[1.23, 22.50, 333.75] * mf"{q:->10.2f}"[------1.23, -----22.50, ----333.75] mf"{q:=>9.1f}"[======1.2, =====22.5, ====333.8] mThose last two are the parser earning its keep:
->10.2fuses-as a fill character and=>9.1fuses=— the two characters the grammar itself uses, as keyword separator and axis assignment. Neither is mistaken for one, because the format spec is terminal.Unit — which spelling:
f"{q:name}"[ 1.23456, 22.5 , 333.75 ] meterf"{q:dim}"[ 1.23456, 22.5 , 333.75 ] lengthMarkup:
f"{q:html}"<span>[ 1.23456, 22.5 , 333.75 ]</span> <span>m</span>f"{q:html-mul}"<span>[ 1.23456, 22.5 , 333.75 ]</span> * <span>m</span>f"{q:html-type-bare}"<span>f32[3]</span> <span>m</span>f"{q:latex}"$[ 1.23456,~ 22.5 ,~333.75 ] \, \mathrm{m}$f"{q:latex-mul-name-.2f}"$[1.23,~22.50,~333.75] \; meter$LaTeX gets its own spelling of the separator, because math mode discards literal whitespace:
bareis\,(the thin space set between a quantity and its unit) andmulis the wider\;. A plain space would render as nothing at all.Composed, and written explicitly — axes are order-independent, and any keyword may be spelled as the assignment it is,
axis=word:f"{q:mul-name-.2f}"[1.23, 22.50, 333.75] * meterf"{q:unit=name}"[ 1.23456, 22.5 , 333.75 ] meterf"{q:markup=latex-sep=mul}"$[ 1.23456,~ 22.5 ,~333.75 ] \; \mathrm{m}$Scalars behave the same, and a bare format spec stays astropy-shaped:
f"{s:.3g}"3.14 mf"{s:mul-.3g}"3.14 * mf"{s:type}"weak_f32[] mThe grammar
A spec is a
--joined run of keywords, optionally ending in a Python format spec applied per element:The parse is total and strictly left-to-right: consume keywords, and the first token that is not one ends keyword parsing — everything from there, including any further
-, is the format spec. That single rule is what keeps the grammar unambiguous once arbitrary format specs are in play: a spec may contain-itself, as a sign flag (-.2f) or a fill character (->10.2f), and neither can be mistaken for a component boundary. Its one consequence is that the format spec goes last.Six axes, each keyword setting exactly one:
call,productproductarray,values,type, or a format specvaluestext,html,latextextsymbol,name,dimsymbolmul,barebareabbrevAliases expand textually into keywords, so one can never mean something the grammar cannot:
compact=call-abbrev,full=call-array,dims=call-dim.Keywords are order-independent (
html-bare==bare-html), which requires a flat namespace — so any keyword may also be written as the assignment it is,axis=word, when two packages want the same word.=says what a keyword does -- sets an axis to a value -- so baredimis shorthand forunit=dim. Bare resolves while exactly one axis claims it, which is every word today; when two claim one, the bare form is ambiguous and the error names both alternatives. It is per-token: a collision costs one assignment on one word, not a rewritten spec.Architecture
Split along the seam it will be cut at:
unxt._src.fmt.engine— domain-agnostic. Fragments, markup, the wadler-lindig feed, theppartsdispatcher, the parser,Spec, layouts, the axis registry. Imports nothing fromunxt,jax,numpyorastropy; a test asserts that from its import list, so the claim can't rot into prose. Destined to become a standalone package namedpparts.unxt._src.fmt.axes— unxt's layer: the axes it registers, the aliases, the array helpers.unxt._pparts— spelled so extraction is dropping one underscore:from unxt._pparts import pparts, PPartbecomesfrom pparts import ...with no other edit. Re-exports 9 names, the intended package surface.Axes and aliases are registered, not hardcoded, so a downstream axis is indistinguishable from a built-in one — order-independent, defaulted when unnamed, layout-scoped. A test registers a fake downstream axis and checks all of that.
calllayout renders through the object's own__pdoc__. That's load-bearing:__pdoc__is where a type says how to reconstruct itself, which is what keepseval(repr(usys)) == usystrue for all nine unit-system realizations.Breaking changes
Unit systems (
💥 boomcommit):reprunitsystem(kpc, ...)orLengthUnitSystem(length=Unit("km"))unitsystem(['kpc', 'Myr', 'solMass', 'rad'])strLTMAUnitSystem(length, time, mass, angle)unitsystem(kpc, Myr, solMass, rad)strf"{usys:dims}"reprnow reconstructs the object. Two details are load-bearing, both found by testing the property rather than assuming it — a single-unit system needs the list form (a lone string is a system name), and the measured-constant realizations need a full-precision scale fallback becauseto_string()truncates past six significant figures.Quantities:
TypeError.f"{q:.2f}"on[1.234, 2.345]→'[1.23, 2.35] m'.bare, sof"{q:.2f}"stays astropy-shaped (3.14 m, not3.14 * m)._repr_html_renders<span>m</span>rather than<span>Unit("m")</span>, and these reprs render a jit-traced value as a shape/dtype summary instead of raisingTracerArrayConversionError.Downstream: galax has ~34 doctest lines that change (nested
LTMAUnitSystem(length=Unit("kpc"), ...)inside potential reprs). It pinsunxt>=1.10.3with no upper bound, so its CI goes red on release regardless of merge order — that needs either aunxt<Xcap before release or a short red window. coordinax and potamides are unaffected.Notable design points
One value-rendering path. A value spec used to have two implementations — a scalar-only astropy-compatible fallback and the engine — so the same spec meant different things depending on whether an unrelated keyword happened to be present. Now
.2fandmul-.2fdiffer only in the separator they name.The format spec is a value of the
valueaxis, not a key beside it. That makestype-.2fan ordinary "value is set twice" error rather than a hand-written consistency check, and it removed the last place the engine hardcoded unxt's vocabulary ("value","values","product"as string literals — invisible to the import-list seam test).Inert axes are detectable, opt-in. An axis is offered to every type and quietly ignored by one with no concept of it — that's what lets a composite forward axes it's never heard of, so
f"{interval:name}"reaches the nested quantities. It also means a spec can be accepted and do nothing (f"{q:name}"onkm / s,f"{q:dims}"on a Quantity). Applicability can't be declared per type — the answer depends on the object tree, unknown before rendering — soWARN_INERT_AXESasks by experiment: re-render with the axis defaulted and see if the output moves. Off by default (each probe is a second render; 1.7×–5×), and it lives on the engine so each consuming library sets it for itself.Bugs found here, fixed separately
Two were split out and merged ahead of this, and this branch is rebased on both:
__pdoc__discarded a caller'scustom=hook — 🐛 fix(quantity): stop__pdoc__discarding a caller'scustom=hook #869._repr_latex_corruptedQuantityMatrix— 🐛 fix(quantity): stop_repr_latex_corrupting units without_repr_latex_#870. This PR's ownppartshad the same[1:-1]assumption, so it carries the hardening across via a sharedunwrap_math.Follow-up
siunitx output (
\qty{1.5}{m}) is designed and scoped in #907, to be built as athird value of the
markupaxis. Plainlatexis deliberately left alone there —siunitx is something a user asks for, not a change to math-mode markup.
Type-scoped overrides (
f"{d:mul-type=Q[.2f]}", so a container can format thequantities in it differently from the raw arrays) are designed and scoped in
#908. That issue also records the one live bug this grammar has: a child with no
ppartsmakes a value spec fatal for the whole container, which is fixable onits own and ahead of the feature.
Notes for review
32 commits, each green and independently revertible; the
💥 boomcommit is the point of no return.Two design decisions argued in the commit messages: the engine uses a module-local
plum.Dispatcher(the global one keys on bare__name__, so two libraries definingppartswould silently merge method tables), and composites nest child parts rather than splicing them flat (a wadler-lindig group is all-or-nothing, so splicing makes every break point break together).Docs: the full grammar, axis table, extension guide and layering live in a new
docs/guides/formatting.md;docs/conventions.mdkeeps only the conventions and links out.Milestone:
v2.1.0, but this carries breaking changes — if you want strict semver that wants a major milestone instead.